Complete auth architecture from user input to database. Every request, cookie, and security layer documented.
Every request passes through multiple security layers. The table below maps each protection to its location in the stack and what it defends against.
| Layer | Protection | Location |
|---|---|---|
| Yup Validation | Email format, password min length | Frontend |
| Submit Cooldown | 2s lock + ref guard against rapid-fire spam | Frontend |
| Next.js Middleware | Checks cookie presence, redirects unauthenticated users to /login | Frontend SSR |
| CORS | Blocks requests from unknown origins | Backend |
| Rate Limiting | 100 req/min in production (prevents brute force) | Backend |
| Helmet | Security headers: XSS protection, clickjacking prevention, CSP in prod | Backend |
| Better Auth Validation | Email format, password min 10 / max 128, duplicate check | Backend |
| Password Hashing | scrypt (Better Auth default) — plaintext never stored | Backend |
| HttpOnly Cookie | JavaScript cannot access session token (XSS protection) | Cookie |
| CSRF Protection | Multi-layered: SameSite=Lax cookies, Origin/Referer header validation, and Fetch Metadata (Sec-Fetch-Site/Mode/Dest) checks — all handled automatically by Better Auth | Cookie + Backend |
| Secure Flag | Set automatically by Better Auth in production — cookie is only sent over HTTPS, preventing token interception on unencrypted connections | Cookie |
| credentials: "include" | Apollo Client config — ensures session cookies are attached to GraphQL requests sent from the SSR layer to the backend | Frontend SSR |
| preExecution Hook | Throws if no valid session — blocks resolver execution entirely | Backend GQL |
| errorFormatter | Sanitizes error details in production (no stack traces leaked) | Backend GQL |
| Query Depth Limit | Max 12 levels of nesting (prevents DoS via deep queries) | Backend GQL |
| cookieCache | 5 min session cache — performance vs. instant revocation tradeoff | Backend |
| Frontend CSP | Content-Security-Policy in next.config.js — restricts script/connect/frame sources. Dynamic connect-src includes GRAPHQL_URL and AUTH_URL origins | Frontend |
| X-Frame-Options | Set to DENY — prevents the app from being embedded in iframes on other domains (clickjacking protection) | Frontend |
| X-Content-Type-Options | Set to nosniff — prevents browsers from MIME-type sniffing, blocking attacks that serve executable content disguised as other file types | Frontend |
| HSTS | Strict-Transport-Security with max-age 2 years, includeSubDomains, preload — forces HTTPS, prevents SSL stripping and downgrade attacks | Frontend |
| Permissions-Policy | Disables camera, microphone, and geolocation APIs — reduces attack surface by blocking access to unnecessary browser capabilities | Frontend |
| X-XSS-Protection | Set to 0 (disabled) as recommended by OWASP — legacy browser XSS auditor was removed from modern browsers and could itself introduce vulnerabilities | Frontend |
| Referrer-Policy | strict-origin-when-cross-origin — sends full URL for same-origin requests, only origin for cross-origin, nothing on HTTPS→HTTP downgrade | Frontend |
| Cache-Control | no-store, no-cache on all /api/auth/*
responses — prevents proxies and browsers from caching session tokens or auth data |
Backend |
| Env Validation | Fail-fast on startup — validateEnv() throws if DATABASE_URL or BETTER_AUTH_SECRET missing | Backend |
| Protected Layout | Server-side getSession() fetch in (protected)/layout.tsx — validates session against backend, not just cookie presence | Frontend SSR |
| Error Code Mapping | ~40 Better Auth error codes grouped into i18n keys via useAuthErrorMessage. Multiple codes map to the same message (e.g. USER_NOT_FOUND, CREDENTIAL_ACCOUNT_NOT_FOUND → INVALID_EMAIL_OR_PASSWORD) to prevent user enumeration | Frontend i18n |
Some authentication and security features are intentionally excluded to keep the starter lightweight and free from external service dependencies. This may change as the project evolves.
| Feature | Status | Note |
|---|---|---|
| MFA / 2FA | Not included | Adds complex UI flow (QR setup, recovery codes, TOTP input) — use Better Auth twoFactor plugin to add |
| Account Lockout | Not included | Rate limiting on auth endpoints covers brute-force protection |
| Idle Session Timeout | Not included | Requires client-side activity tracking — only absolute expiry is used |
| Email Verification | Not included | Requires email provider (SendGrid, AWS SES) and verification UI |
| Password Reset | UI only | Requires email provider for sending reset links — same dependency as email verification |
| Password Strength Meter | Not included | Heavy dependency (~400KB) — min-length rule used instead |
Three roles are defined in access.ts
using Better Auth's createAccessControl. Each role declares per-resource permissions that
determine what actions a user can perform. New users are assigned the viewer role by default.
| Role | Data Permissions | Admin Access |
|---|---|---|
| admin | View + edit on orders, customers and products | Full access to admin endpoints (list.users, set.role, ban.user, update.user) |
| editor | View + edit on orders, customers and products | No access |
| viewer | View only (read-only on all resources) | No access |
Enforcement points:
• Backend: Admin endpoints (/api/auth/admin/*)
are restricted to admin role by Better Auth's admin plugin. Non-admin requests receive
403 Forbidden.
• Frontend: User Management page checks session.user.role and renders an "Access Denied" screen for non-admin users. Only admins see role assignment controls.
• Self-demotion: If an admin changes their own role to editor or viewer, the app triggers an automatic sign-out and redirects to the login page.
• GraphQL: Resolvers currently use a binary auth gate (authenticated or not). Per-resource permissions are defined and ready to be checked in resolvers when needed.
| Parameter | Value | Description |
|---|---|---|
| expiresIn | 7 days (604800s) | Session lifetime |
| updateAge | 1 day (86400s) | Token refresh interval |
| cookieCache | 5 min (300s) | Better Auth stores encoded session data directly in the cookie as a cache. When getSession() is called, it first checks this cached payload — if still within the 5 min window, it returns the result without querying the database. After expiry, the next getSession() call hits the DB and refreshes the cache |
| Table | Key Columns |
|---|---|
| user | id, name, email, emailVerified, image, role, banned, banReason, banExpires, createdAt, updatedAt |
| session | id, token (unique), userId (FK → user, ON DELETE CASCADE), expiresAt, ipAddress, userAgent, impersonatedBy, createdAt, updatedAt |
| account | id, accountId, userId (FK → user, ON DELETE CASCADE), providerId, password (scrypt hash), accessToken, refreshToken, idToken, accessTokenExpiresAt, refreshTokenExpiresAt, scope, createdAt, updatedAt |
| verification | id, identifier, value, expiresAt, createdAt, updatedAt |